/*
 * Copyright 2002-2018 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.web.servlet.view;

import org.springframework.beans.factory.BeanNameAware;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.ContextExposingHttpServletRequest;
import org.springframework.web.context.support.WebApplicationObjectSupport;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.support.RequestContext;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.*;

/**
 * Abstract base class for {@link org.springframework.web.servlet.View}
 * implementations. Subclasses should be JavaBeans, to allow for
 * convenient configuration as Spring-managed bean instances.
 *
 * <p>Provides support for static attributes, to be made available to the view,
 * with a variety of ways to specify them. Static attributes will be merged
 * with the given dynamic attributes (the model that the controller returned)
 * for each render operation.
 *
 * <p>Extends {@link WebApplicationObjectSupport}, which will be helpful to
 * some views. Subclasses just need to implement the actual rendering.
 *
 * @author Rod Johnson
 * @author Juergen Hoeller
 * @see #setAttributes
 * @see #setAttributesMap
 * @see #renderMergedOutputModel
 */
public abstract class AbstractView extends WebApplicationObjectSupport implements View, BeanNameAware {

    /**
     * Default content type. Overridable as bean property.
     */
    public static final String DEFAULT_CONTENT_TYPE = "text/html;charset=ISO-8859-1";

    /**
     * Initial size for the temporary output byte array (if any).
     */
    private static final int OUTPUT_BYTE_ARRAY_INITIAL_SIZE = 4096;
    private final Map<String, Object> staticAttributes = new LinkedHashMap<>();
    @Nullable
    private String contentType = DEFAULT_CONTENT_TYPE;
    @Nullable
    private String requestContextAttribute;
    private boolean exposePathVariables = true;

    private boolean exposeContextBeansAsAttributes = false;

    @Nullable
    private Set<String> exposedContextBeanNames;

    @Nullable
    private String beanName;

    /**
     * Return the content type for this view.
     */
    @Override
    @Nullable
    public String getContentType() {
        return this.contentType;
    }

    /**
     * Set the content type for this view.
     * Default is "text/html;charset=ISO-8859-1".
     * <p>May be ignored by subclasses if the view itself is assumed
     * to set the content type, e.g. in case of JSPs.
     */
    public void setContentType(@Nullable String contentType) {
        this.contentType = contentType;
    }

    /**
     * Return the name of the RequestContext attribute, if any.
     */
    @Nullable
    public String getRequestContextAttribute() {
        return this.requestContextAttribute;
    }

    /**
     * Set the name of the RequestContext attribute for this view.
     * Default is none.
     */
    public void setRequestContextAttribute(@Nullable String requestContextAttribute) {
        this.requestContextAttribute = requestContextAttribute;
    }

    /**
     * Set static attributes as a CSV string.
     * Format is: attname0={value1},attname1={value1}
     * <p>"Static" attributes are fixed attributes that are specified in
     * the View instance configuration. "Dynamic" attributes, on the other hand,
     * are values passed in as part of the model.
     */
    public void setAttributesCSV(@Nullable String propString) throws IllegalArgumentException {
        if (propString != null) {
            StringTokenizer st = new StringTokenizer(propString, ",");
            while (st.hasMoreTokens()) {
                String tok = st.nextToken();
                int eqIdx = tok.indexOf('=');
                if (eqIdx == -1) {
                    throw new IllegalArgumentException(
                            "Expected '=' in attributes CSV string '" + propString + "'");
                }
                if (eqIdx >= tok.length() - 2) {
                    throw new IllegalArgumentException(
                            "At least 2 characters ([]) required in attributes CSV string '" + propString + "'");
                }
                String name = tok.substring(0, eqIdx);
                String value = tok.substring(eqIdx + 1);

                // Delete first and last characters of value: { and }
                value = value.substring(1);
                value = value.substring(0, value.length() - 1);

                addStaticAttribute(name, value);
            }
        }
    }

    /**
     * Set static attributes for this view from a
     * {@code java.util.Properties} object.
     * <p>"Static" attributes are fixed attributes that are specified in
     * the View instance configuration. "Dynamic" attributes, on the other hand,
     * are values passed in as part of the model.
     * <p>This is the most convenient way to set static attributes. Note that
     * static attributes can be overridden by dynamic attributes, if a value
     * with the same name is included in the model.
     * <p>Can be populated with a String "value" (parsed via PropertiesEditor)
     * or a "props" element in XML bean definitions.
     *
     * @see org.springframework.beans.propertyeditors.PropertiesEditor
     */
    public void setAttributes(Properties attributes) {
        CollectionUtils.mergePropertiesIntoMap(attributes, this.staticAttributes);
    }

    /**
     * Allow Map access to the static attributes of this view,
     * with the option to add or override specific entries.
     * <p>Useful for specifying entries directly, for example via
     * "attributesMap[myKey]". This is particularly useful for
     * adding or overriding entries in child view definitions.
     */
    public Map<String, Object> getAttributesMap() {
        return this.staticAttributes;
    }

    /**
     * Set static attributes for this view from a Map. This allows to set
     * any kind of attribute values, for example bean references.
     * <p>"Static" attributes are fixed attributes that are specified in
     * the View instance configuration. "Dynamic" attributes, on the other hand,
     * are values passed in as part of the model.
     * <p>Can be populated with a "map" or "props" element in XML bean definitions.
     *
     * @param attributes a Map with name Strings as keys and attribute objects as values
     */
    public void setAttributesMap(@Nullable Map<String, ?> attributes) {
        if (attributes != null) {
            attributes.forEach(this::addStaticAttribute);
        }
    }

    /**
     * Add static data to this view, exposed in each view.
     * <p>"Static" attributes are fixed attributes that are specified in
     * the View instance configuration. "Dynamic" attributes, on the other hand,
     * are values passed in as part of the model.
     * <p>Must be invoked before any calls to {@code render}.
     *
     * @param name  the name of the attribute to expose
     * @param value the attribute value to expose
     * @see #render
     */
    public void addStaticAttribute(String name, Object value) {
        this.staticAttributes.put(name, value);
    }

    /**
     * Return the static attributes for this view. Handy for testing.
     * <p>Returns an unmodifiable Map, as this is not intended for
     * manipulating the Map but rather just for checking the contents.
     *
     * @return the static attributes in this view
     */
    public Map<String, Object> getStaticAttributes() {
        return Collections.unmodifiableMap(this.staticAttributes);
    }

    /**
     * Return whether to add path variables to the model or not.
     */
    public boolean isExposePathVariables() {
        return this.exposePathVariables;
    }

    /**
     * Specify whether to add path variables to the model or not.
     * <p>Path variables are commonly bound to URI template variables through the {@code @PathVariable}
     * annotation. They're are effectively URI template variables with type conversion applied to
     * them to derive typed Object values. Such values are frequently needed in views for
     * constructing links to the same and other URLs.
     * <p>Path variables added to the model override static attributes (see {@link #setAttributes(Properties)})
     * but not attributes already present in the model.
     * <p>By default this flag is set to {@code true}. Concrete view types can override this.
     *
     * @param exposePathVariables {@code true} to expose path variables, and {@code false} otherwise
     */
    public void setExposePathVariables(boolean exposePathVariables) {
        this.exposePathVariables = exposePathVariables;
    }

    /**
     * Set whether to make all Spring beans in the application context accessible
     * as request attributes, through lazy checking once an attribute gets accessed.
     * <p>This will make all such beans accessible in plain {@code ${...}}
     * expressions in a JSP 2.0 page, as well as in JSTL's {@code c:out}
     * value expressions.
     * <p>Default is "false". Switch this flag on to transparently expose all
     * Spring beans in the request attribute namespace.
     * <p><b>NOTE:</b> Context beans will override any custom request or session
     * attributes of the same name that have been manually added. However, model
     * attributes (as explicitly exposed to this view) of the same name will
     * always override context beans.
     *
     * @see #getRequestToExpose
     */
    public void setExposeContextBeansAsAttributes(boolean exposeContextBeansAsAttributes) {
        this.exposeContextBeansAsAttributes = exposeContextBeansAsAttributes;
    }

    /**
     * Specify the names of beans in the context which are supposed to be exposed.
     * If this is non-null, only the specified beans are eligible for exposure as
     * attributes.
     * <p>If you'd like to expose all Spring beans in the application context, switch
     * the {@link #setExposeContextBeansAsAttributes "exposeContextBeansAsAttributes"}
     * flag on but do not list specific bean names for this property.
     */
    public void setExposedContextBeanNames(String... exposedContextBeanNames) {
        this.exposedContextBeanNames = new HashSet<>(Arrays.asList(exposedContextBeanNames));
    }

    /**
     * Return the view's name. Should never be {@code null},
     * if the view was correctly configured.
     */
    @Nullable
    public String getBeanName() {
        return this.beanName;
    }

    /**
     * Set the view's name. Helpful for traceability.
     * <p>Framework code must call this when constructing views.
     */
    @Override
    public void setBeanName(@Nullable String beanName) {
        this.beanName = beanName;
    }

    /**
     * Prepares the view given the specified model, merging it with static
     * attributes and a RequestContext attribute, if necessary.
     * Delegates to renderMergedOutputModel for the actual rendering.
     *
     * @see #renderMergedOutputModel
     */
    @Override
    public void render(@Nullable Map<String, ?> model, HttpServletRequest request,
                       HttpServletResponse response) throws Exception {

        if (logger.isDebugEnabled()) {
            logger.debug("View " + formatViewName() +
                    ", model " + (model != null ? model : Collections.emptyMap()) +
                    (this.staticAttributes.isEmpty() ? "" : ", static attributes " + this.staticAttributes));
        }

        Map<String, Object> mergedModel = createMergedOutputModel(model, request, response);
        prepareResponse(request, response);
        renderMergedOutputModel(mergedModel, getRequestToExpose(request), response);
    }

    /**
     * Creates a combined output Map (never {@code null}) that includes dynamic values and static attributes.
     * Dynamic values take precedence over static attributes.
     */
    protected Map<String, Object> createMergedOutputModel(@Nullable Map<String, ?> model,
                                                          HttpServletRequest request, HttpServletResponse response) {

        @SuppressWarnings("unchecked")
        Map<String, Object> pathVars = (this.exposePathVariables ?
                (Map<String, Object>) request.getAttribute(View.PATH_VARIABLES) : null);

        // Consolidate static and dynamic model attributes.
        int size = this.staticAttributes.size();
        size += (model != null ? model.size() : 0);
        size += (pathVars != null ? pathVars.size() : 0);

        Map<String, Object> mergedModel = new LinkedHashMap<>(size);
        mergedModel.putAll(this.staticAttributes);
        if (pathVars != null) {
            mergedModel.putAll(pathVars);
        }
        if (model != null) {
            mergedModel.putAll(model);
        }

        // Expose RequestContext?
        if (this.requestContextAttribute != null) {
            mergedModel.put(this.requestContextAttribute, createRequestContext(request, response, mergedModel));
        }

        return mergedModel;
    }

    /**
     * Create a RequestContext to expose under the specified attribute name.
     * <p>The default implementation creates a standard RequestContext instance for the
     * given request and model. Can be overridden in subclasses for custom instances.
     *
     * @param request current HTTP request
     * @param model   combined output Map (never {@code null}),
     *                with dynamic values taking precedence over static attributes
     * @return the RequestContext instance
     * @see #setRequestContextAttribute
     * @see org.springframework.web.servlet.support.RequestContext
     */
    protected RequestContext createRequestContext(
            HttpServletRequest request, HttpServletResponse response, Map<String, Object> model) {

        return new RequestContext(request, response, getServletContext(), model);
    }

    /**
     * Prepare the given response for rendering.
     * <p>The default implementation applies a workaround for an IE bug
     * when sending download content via HTTPS.
     *
     * @param request  current HTTP request
     * @param response current HTTP response
     */
    protected void prepareResponse(HttpServletRequest request, HttpServletResponse response) {
        if (generatesDownloadContent()) {
            response.setHeader("Pragma", "private");
            response.setHeader("Cache-Control", "private, must-revalidate");
        }
    }

    /**
     * Return whether this view generates download content
     * (typically binary content like PDF or Excel files).
     * <p>The default implementation returns {@code false}. Subclasses are
     * encouraged to return {@code true} here if they know that they are
     * generating download content that requires temporary caching on the
     * client side, typically via the response OutputStream.
     *
     * @see #prepareResponse
     * @see javax.servlet.http.HttpServletResponse#getOutputStream()
     */
    protected boolean generatesDownloadContent() {
        return false;
    }

    /**
     * Get the request handle to expose to {@link #renderMergedOutputModel}, i.e. to the view.
     * <p>The default implementation wraps the original request for exposure of Spring beans
     * as request attributes (if demanded).
     *
     * @param originalRequest the original servlet request as provided by the engine
     * @return the wrapped request, or the original request if no wrapping is necessary
     * @see #setExposeContextBeansAsAttributes
     * @see #setExposedContextBeanNames
     * @see org.springframework.web.context.support.ContextExposingHttpServletRequest
     */
    protected HttpServletRequest getRequestToExpose(HttpServletRequest originalRequest) {
        if (this.exposeContextBeansAsAttributes || this.exposedContextBeanNames != null) {
            WebApplicationContext wac = getWebApplicationContext();
            Assert.state(wac != null, "No WebApplicationContext");
            return new ContextExposingHttpServletRequest(originalRequest, wac, this.exposedContextBeanNames);
        }
        return originalRequest;
    }

    /**
     * Subclasses must implement this method to actually render the view.
     * <p>The first step will be preparing the request: In the JSP case,
     * this would mean setting model objects as request attributes.
     * The second step will be the actual rendering of the view,
     * for example including the JSP via a RequestDispatcher.
     *
     * @param model    combined output Map (never {@code null}),
     *                 with dynamic values taking precedence over static attributes
     * @param request  current HTTP request
     * @param response current HTTP response
     * @throws Exception if rendering failed
     */
    protected abstract void renderMergedOutputModel(
            Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception;


    /**
     * Expose the model objects in the given map as request attributes.
     * Names will be taken from the model Map.
     * This method is suitable for all resources reachable by {@link javax.servlet.RequestDispatcher}.
     *
     * @param model   a Map of model objects to expose
     * @param request current HTTP request
     */
    protected void exposeModelAsRequestAttributes(Map<String, Object> model,
                                                  HttpServletRequest request) throws Exception {

        model.forEach((name, value) -> {
            if (value != null) {
                request.setAttribute(name, value);
            }
            else {
                request.removeAttribute(name);
            }
        });
    }

    /**
     * Create a temporary OutputStream for this view.
     * <p>This is typically used as IE workaround, for setting the content length header
     * from the temporary stream before actually writing the content to the HTTP response.
     */
    protected ByteArrayOutputStream createTemporaryOutputStream() {
        return new ByteArrayOutputStream(OUTPUT_BYTE_ARRAY_INITIAL_SIZE);
    }

    /**
     * Write the given temporary OutputStream to the HTTP response.
     *
     * @param response current HTTP response
     * @param baos     the temporary OutputStream to write
     * @throws IOException if writing/flushing failed
     */
    protected void writeToResponse(HttpServletResponse response, ByteArrayOutputStream baos) throws IOException {
        // Write content type and also length (determined via byte array).
        response.setContentType(getContentType());
        response.setContentLength(baos.size());

        // Flush byte array to servlet output stream.
        ServletOutputStream out = response.getOutputStream();
        baos.writeTo(out);
        out.flush();
    }

    /**
     * Set the content type of the response to the configured
     * {@link #setContentType(String) content type} unless the
     * {@link View#SELECTED_CONTENT_TYPE} request attribute is present and set
     * to a concrete media type.
     */
    protected void setResponseContentType(HttpServletRequest request, HttpServletResponse response) {
        MediaType mediaType = (MediaType) request.getAttribute(View.SELECTED_CONTENT_TYPE);
        if (mediaType != null && mediaType.isConcrete()) {
            response.setContentType(mediaType.toString());
        }
        else {
            response.setContentType(getContentType());
        }
    }

    @Override
    public String toString() {
        return getClass().getName() + ": " + formatViewName();
    }

    protected String formatViewName() {
        return (getBeanName() != null ? "name '" + getBeanName() + "'" : "[" + getClass().getSimpleName() + "]");
    }

}
